Skip to content

feat: add X-Docker-Agent-Version and X-Docker-Desktop-Version headers to built-in tools#2795

Merged
dgageot merged 7 commits into
docker:mainfrom
dgageot:identity-headers
May 18, 2026
Merged

feat: add X-Docker-Agent-Version and X-Docker-Desktop-Version headers to built-in tools#2795
dgageot merged 7 commits into
docker:mainfrom
dgageot:identity-headers

Conversation

@dgageot
Copy link
Copy Markdown
Member

@dgageot dgageot commented May 13, 2026

What

Adds two HTTP identity headers to every outbound request made by the api, fetch, and openapi built-in tools, on top of the existing User-Agent:

Header Value When
User-Agent Cagent/<version> (<goos>; <goarch>) Always (unchanged)
X-Docker-Agent-Version <version> (e.g. dev, 1.2.3) Always
X-Docker-Desktop-Version e.g. 4.74.0 Only when Docker Desktop is reachable on the host

Operator-supplied headers in the agent config still win over these defaults — SetIdentity is called first, then caller headers.

Why

Backends that receive built-in-tool traffic (Docker Hub APIs, Hub gateway, ...) want to attribute requests to a specific docker-agent install and the Docker Desktop version it's running alongside, the same way other Docker traffic is already attributed. User-Agent was the only signal so far and is brittle to parse.

How

  • New pkg/useragent.SetIdentity(req) is the single source of truth for these headers. All three built-in tools route through it, including the robots.txt fetch in the fetch tool.
  • New pkg/desktop.GetVersion(ctx) reads currentVersion from Docker Desktop's backend.sock /update endpoint, with:
    • 5-minute TTL memoization (memoize.Call[string]) — recovers automatically if Desktop starts after docker-agent or is upgraded mid-session, while still costing one socket call per tool request at most;
    • 2 s internal timeout so a stale/missing socket can never stall a hot path;
    • returns "" when Desktop isn't running, in which case the header is omitted.

No new dependencies (go-memoize and go-cache were already vendored).

Privacy / security note

X-Docker-Agent-Version adds no information that User-Agent didn't already broadcast. X-Docker-Desktop-Version is genuinely new data sent to whichever host the operator configured. It's no more leaky than User-Agent is for <goos>; <goarch>, but if a privacy/opt-out mode is desired we can layer one on top later. Not adding one now keeps this PR focused.

Tests

  • pkg/useragent: pins User-Agent + X-Docker-Agent-Version on a fresh request.
  • api, fetch, openapi: each existing header test now also asserts the two identity headers reach the test server.
  • All tests in modified packages pass; task lint is clean.

Commits

  1. feat: add identity headers for docker-agent and Docker Desktop tracking
  2. refactor: use SetIdentity() in api tool
  3. refactor: use SetIdentity() in fetch tool
  4. refactor: use SetIdentity() in openapi tool
  5. fix(desktop): make Desktop version lookup TTL-based and context-independent
  6. refactor: simplify Desktop version lookup and useragent docs
  7. refactor(desktop): plumb context through GetVersion

dgageot added 7 commits May 13, 2026 18:08
Introduce SetIdentity() to centralize HTTP identity headers (User-Agent,
X-Docker-Agent-Version, X-Docker-Desktop-Version) used across built-in tools.

Add pkg/desktop/version.go to fetch Docker Desktop version from the backend
socket's /update endpoint once per process (memoized, 2s timeout).
Update api tool to use the new SetIdentity() helper, ensuring consistent
identity header formatting across all built-in tools. Add test coverage
for identity headers.
Update fetch tool to use SetIdentity(), including robots.txt requests.
Remove redundant userAgent parameter from fetchRobots(). Add test coverage
for identity headers.
Update openapi tool to use SetIdentity(), establishing unified identity
header handling. Add test coverage for identity headers.
@dgageot dgageot requested a review from a team as a code owner May 13, 2026 18:31
Copy link
Copy Markdown

@docker-agent docker-agent left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assessment: 🟡 NEEDS ATTENTION

One medium confirmed finding in the new code.

Comment thread pkg/desktop/version.go
CurrentVersion string `json:"currentVersion"`
}
_ = ClientBackend.Get(ctx, "/update", &info)
return info.CurrentVersion, nil
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Empty Desktop version cached for full 5-minute TTL when Desktop is unavailable at first call

GetVersion always returns (info.CurrentVersion, nil) — even when the HTTP call to Desktop's /update endpoint fails (Desktop not running, socket not present, timeout, etc.). In that case info.CurrentVersion is the zero value "", and since go-memoize only caches results where error == nil, the empty string gets cached for the full 5-minute TTL.

This contradicts the PR description's claim that the lookup "recovers automatically if Desktop starts after docker-agent": if GetVersion is first called before Desktop is ready, the X-Docker-Desktop-Version header will be silently omitted for up to 5 minutes even after Desktop becomes available — rather than recovering on the next request.

Fix: return a non-nil error when the lookup fails so go-memoize does not cache the failure:

if err := ClientBackend.Get(ctx, "/update", &info); err != nil {
    return "", err   // not cached; next caller retries
}
return info.CurrentVersion, nil

This way a transient failure (Desktop not yet up) never poisons the cache.

Comment thread pkg/desktop/version.go
var info struct {
CurrentVersion string `json:"currentVersion"`
}
_ = ClientBackend.Get(ctx, "/update", &info)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: the _ = ClientBackend.Get(...) ignores the error and lets the memoizer cache the empty string for the full 5 minutes. That's intentional for the steady-state "Desktop not running" case (good — we don't want to hammer the socket), but it also means a transient failure (e.g. backend.sock momentarily unavailable while Desktop restarts, or a 2 s timeout under load) gets pinned for 5 minutes before we try again.

If we wanted to differentiate, returning a non-nil error from the closure on Get failures would let go-memoize re-attempt sooner (its TTL applies per cache entry, but errors aren't cached the same way — worth double-checking the library semantics). Not a blocker for this PR — the header is best-effort by design — but worth a comment in the code so the next reader knows the swallow is deliberate.

Comment thread pkg/desktop/version.go
// recovers automatically once Desktop comes up;
// - if Desktop is upgraded mid-session, the new version is picked up
// within at most one TTL.
var versionMemoizer = memoize.NewMemoizer(5*time.Minute, 10*time.Minute)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: what drove the 5 min TTL (and the 10 min cleanup interval)? The PR description mentions "recovers automatically … upgraded mid-session" which argues for some refresh, but 5 minutes is a fairly arbitrary middle ground — Desktop upgrades are rare (once a week), the lookup is cheap-ish (one local socket call with a 2 s ceiling), and the value almost never changes within a session.

A short rationale comment near the NewMemoizer call (e.g. "5 min keeps the steady-state cost ≤ 1 socket call per 5 min per process while still picking up Desktop upgrades within a few minutes") would save the next reader the same question. Happy to leave the value as-is.

req.Header.Set("User-Agent", Header)
req.Header.Set(HeaderAgentVersion, version.Version)
if v := desktop.GetVersion(req.Context()); v != "" {
req.Header.Set(HeaderDesktopVersion, v)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking (privacy/scope): SetIdentity is wired into every api / fetch / openapi outbound request, which means X-Docker-Desktop-Version is broadcast to every host the operator points a built-in tool at — not just Docker-owned backends. The PR description acknowledges this and defers an opt-out, which I think is fine, but two thoughts for the next iteration:

  1. Consider scoping the Desktop header to known Docker hosts (Hub, Hub gateway, *.docker.com, *.docker.io) by default. The agent-version header is harmless to broadcast; the Desktop-version one is the genuinely new signal and the one most likely to surprise a security-minded operator.
  2. If we keep it global, the README / docs should mention the new header explicitly so operators auditing outbound traffic aren't surprised. I don't see a docs update in this PR — happy to merge without one if a follow-up is tracked.

Neither blocks this PR.


assert.Equal(t, Header, req.Header.Get("User-Agent"))
assert.Equal(t, version.Version, req.Header.Get(HeaderAgentVersion))
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: two behaviours that the PR description calls out as load-bearing don't have a unit test here:

  1. Desktop unreachable → X-Docker-Desktop-Version omitted, not set to empty string. Today this is implicit (because no Desktop socket is around in CI) — the existing tests in api/fetch/openapi assert presence of the agent-version header but don't assert absence of the Desktop one. A test that injects a stub returning "" and asserts req.Header.Get(HeaderDesktopVersion) == "" and that the key is not present in req.Header would pin the contract.
  2. Operator-supplied override wins. The setHeaders callers apply caller headers after SetIdentity, which is the documented precedence — but there's no test pinning that a User-Agent or X-Docker-Agent-Version in the operator config overrides the defaults. pkg/tools/builtin/fetch/fetch_test.go has a TestFetch_Headers_OverrideDefaults for User-Agent; an analogous one for X-Docker-Agent-Version (and ideally one in api/openapi) would catch a future refactor that flips the order.

Both are quick to add and would lock in the invariants the PR description leans on.

@dgageot dgageot merged commit 7d55a3a into docker:main May 18, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants